[SPARK-59696][SQL] Skip the redundant second modulo in integral MathUtils.pmod for positive divisors - #58906
[SPARK-59696][SQL] Skip the redundant second modulo in integral MathUtils.pmod for positive divisors#58906david-mollitor-db wants to merge 3 commits into
Conversation
6153f7c to
a61d12a
Compare
a61d12a to
d01a4a0
Compare
dongjoon-hyun
left a comment
There was a problem hiding this comment.
For code change, it would be great if you can make a PR with the JIRA issue for trace-ability, @david-mollitor-db .
d01a4a0 to
41489ef
Compare
|
Thank you for the review, @dongjoon-hyun, and my apologies for opening this without a JIRA ID — I'm a newcomer to Spark and still getting familiar with the contribution process. I've filed SPARK-59696 and updated the PR title and commit message to reference it. Could you please take another look when you have a chance? Thank you for your patience. |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for filing the JIRA issue and updating the PR, @david-mollitor-db .
The logic looks correct to me. For n > 0 and r < 0, r is in (-n, 0), so r + n is in (0, n) without overflow and the second % n is a no-op. The n < 0 path is identical to the existing code, and both eval and codegen go through the same MathUtils.pmod, so they stay consistent. Keeping the Float/Double overloads as-is also makes sense.
I have only a few minor comments.
- The PR title,
Remove redundant second modulo ..., seems to describe the earlier revision (unconditional removal). Since this PR skips it only for positive divisors, could you revise the title to match, e.g.,Skip the redundant second modulo in integral MathUtils.pmod for positive divisors? It will be the commit title. - The existing comment above your new one says
adjusted to share the sign of n, which is not accurate fora > 0, n < 0(a positiveris returned as-is) and doesn't match your PR description (it shares the sign of the dividend). Since the new comment sits right next to it, it would be nice to fix it together. - (nit) The
Byte/Shortoverloads use a one-lineval result = ...form whileInt/Longuse the three-lineif / else if / elseform. It would be good to be consistent.
Let's wait for the CI results.
|
One more note for the record, in the interest of transparency: My initial implementation dropped the second I've corrected the implementation to keep the modulo when the divisor is negative, and added regression cases in |
…tils.pmod for positive divisors
The integral `MathUtils.pmod` overloads (Int, Long, Byte, Short) compute
`if (r < 0) (r + n) % n else r`, where `r = a % n`. When the divisor `n > 0`,
a negative `r` lies in `(-n, 0)`, so `r + n` is already in `[0, n)` and the
trailing `% n` is a no-op; it is skipped there. When `n < 0`, `r + n` can fall
below `n` and still needs reducing, so the `% n` is retained:
if (r >= 0) r
else if (n > 0) r + n
else (r + n) % n
This is behavior-preserving for every input, including negative divisors. An
unconditional drop of the `% n` would change e.g. `pmod(-3, -5)` from `-3` to
`-8` (out of range) and diverge from the (unchanged) decimal `pmod` path. The
guarded form was verified equivalent to the original against an exhaustive
oracle: all byte and short (a, n) pairs, and all 2^32 int dividends across
positive and negative divisors.
Added regression cases in `ArithmeticExpressionSuite` for a negative dividend
with a negative divisor -- the `r < 0`, `n < 0` path that an unconditional drop
would break and that existing tests did not cover.
The float/double overloads intentionally keep the second modulo: floating-point
rounding can make `r + n` round up to exactly `n`, which `% n` folds back to 0.
`pmod` backs the `pmod` SQL function and `HashPartitioning`'s per-row
partition-id expression; for positive divisors this removes an integer division
from the hot path (~3.3x faster on the changed branch in a JMH microbenchmark;
numbers in the PR description).
Co-authored-by: Isaac <no-reply@databricks.com>
41489ef to
a157084
Compare
|
Thanks @dongjoon-hyun. Addressed all three: retitled to "Skip the redundant second modulo ... for positive divisors", corrected the comment above |
|
Hi @dongjoon-hyun, gentle nudge on this one -- it should be ready for another look whenever you have a moment. Thanks again for your time! |
The integral pmod overloads used a multi-line brace-less if/else if/else, which the Databricks Scala style guide flags (curly braces required unless a one-line if/else ternary). Restore the one-line ternary form to match the file's Float/Double overloads and the pre-existing style. No logic change. Co-authored-by: Isaac <no-reply@databricks.com>
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the update, @david-mollitor-db. I reviewed the latest commit (0f45189).
The optimization itself looks correct and behavior-preserving. The old and new integral bodies return identical results for all byte/short pairs and for 2e8 random int and long pairs each, plus MinValue/MaxValue edge values, and the one-line form compiles to the same bytecode as the previous revision. Most of the inline comments are about the new comments and tests rather than the code change itself.
- The new
n < 0sentence in the header comment doesn't hold forInt/Longwhenr + noverflows, e.g.pmod(-1, Int.MinValue) == Int.MaxValue(L93). The overflow itself is pre-existing and silent even in ANSI mode, so it may deserve a separate JIRA (L104). - The new test comment says
pmodis "only positive for a positive divisor", which the assertions right above it contradict (L585). - The new negative-divisor cases can't detect replacing
(r + n) % nwithr, because only overflow inputs distinguish the two (L586). - Optional: a branchless form for non-constant divisors (L109),
Byte/Shortdelegation (L114),testNumericDataTypes(L588),Math.floorMod(L97), and theFloat/Double+Infinitycase (L92).
In addition,
- The PR description, which becomes the commit message, still shows the previous three-line snippet and the review history ("an earlier revision ...", the "drop-all (rejected)" column). Could you update the snippet to the current one-line form and drop the history? Also, on the literal-divisor path (
HashPartitioning), C2 already strength-reduces% numPartitionsinto a multiply/shift, so the gain there comes from the branchless sign fix-up rather than from taking an integer division off the hot path. - Outside of this diff (not blockers; they can be follow-ups):
- The user-facing docs still say "Returns the positive value of
expr1modexpr2" (arithmetic.scala#L1123, and the same wording infunctions.scala, PySpark, and R), whilepmod(-7, -3)returns-1. - The Decimal path (arithmetic.scala#L1303 and its codegen) keeps the same redundant second remainder for
n > 0, which is also an exact no-op for decimals. - The
Pmod.doGenCodecomment "the same fixed algorithm for every primitive numeric type" (arithmetic.scala#L1242) is now slightly loose; "same semantics" would be more accurate.
- The user-facing docs still say "Returns the positive value of
Let's wait for the CI results.
| def floorMod(a: Long, b: Long): Long = withOverflow(Math.floorMod(a, b)) | ||
|
|
||
| // Positive modulo (`pmod`): the remainder `a % n` adjusted to share the sign of `n`. | ||
| // Positive modulo (`pmod`): the remainder `a % n` shifted to be non-negative when the divisor |
There was a problem hiding this comment.
nit: this header also covers the Float/Double overloads (L100 mentions them), which return NaN for a +Infinity divisor and a negative finite dividend, e.g. pmod(-1.0, Double.PositiveInfinity) is (-1.0 + Inf) % Inf = NaN. It's pre-existing and minor, but you may want to scope the non-negative guarantee to finite inputs or to the integral overloads.
|
|
||
| // Positive modulo (`pmod`): the remainder `a % n` adjusted to share the sign of `n`. | ||
| // Positive modulo (`pmod`): the remainder `a % n` shifted to be non-negative when the divisor | ||
| // `n > 0`; for `n < 0` the result instead shares the sign of the dividend `a`. |
There was a problem hiding this comment.
For Int/Long, this n < 0 statement doesn't hold when r + n overflows in the retained (r + n) % n (only possible for n < -2^30 on Int and n < -2^62 on Long). For example,
pmod(-1, Int.MinValue) == 2147483647pmod(-2, -2147483647) == 0pmod(-1073741824, -1073741825) == 1073741822pmod(-1L, Long.MinValue) == Long.MaxValue
It does hold for Byte/Short (computed in Int) and for non-overflowing Int/Long inputs. Could you add an overflow caveat here, or limit the statement to the n > 0 guarantee? The PR description has the same sentence.
| // The `r < 0` branch shifts the remainder by `n`. When `n > 0`, `r` lies in `(-n, 0)` so the | ||
| // shifted value `r + n` is already in `[0, n)` and the extra `% n` is a no-op -- it is skipped. |
There was a problem hiding this comment.
nit: for n > 0, the new fast path is exactly java.lang.Math.floorMod(a, n), whose result the JDK guarantees to be in [0, n) for a positive divisor (on JDK 17/21 it is also one % plus a conditional add). So this range argument could simply refer to that. It's only a wording suggestion; the current code is fine.
| def pmod(a: Int, n: Int): Int = { | ||
| val r = a % n | ||
| if (r < 0) (r + n) % n else r | ||
| if (r >= 0) r else if (n > 0) r + n else (r + n) % n |
There was a problem hiding this comment.
This is pre-existing (the old body behaves identically), so it's not a regression of this PR. But since this line is rewritten: the retained (r + n) % n silently wraps around for Int when n < -2^30 (and for Long at L109 when n < -2^62), returning a non-negative value for a negative dividend.
SELECT pmod(-1, CAST(-2147483648 AS INT))returns2147483647, whileBIGINTandDECIMALreturn-1for the same values, and theByte/Shortoverloads return-1forpmod(-1, MinValue).pmod(-1073741824, -1073741825) == 1073741822, which is not even congruent to the dividend modulon.- ANSI mode doesn't raise
ARITHMETIC_OVERFLOWbecausefailOnErroronly covers the zero divisor.
Without overflow, (r + n) % n == r, so else r would fix it. Since that changes released results for these inputs, a separate JIRA seems better than this PR.
| def pmod(a: Byte, n: Byte): Byte = { | ||
| val r = a % n | ||
| if (r < 0) ((r + n) % n).toByte else r.toByte | ||
| (if (r >= 0) r else if (n > 0) r + n else (r + n) % n).toByte |
There was a problem hiding this comment.
Optional: a % n on Byte/Short is already Int arithmetic, so r + n can't overflow here and the (r + n) % n arm always equals r. So the Byte (here) and Short (L119) overloads could simply delegate to the Int one. It is exactly equivalent (checked for all byte and short pairs) and leaves one copy of the branch chain per width, which also keeps them consistent by construction.
def pmod(a: Byte, n: Byte): Byte = pmod(a.toInt, n.toInt).toByte
def pmod(a: Short, n: Short): Short = pmod(a.toInt, n.toInt).toShortGenerated code keeps calling pmod(byte, byte) / pmod(short, short), so codegen is unaffected.
| checkEvaluation(Pmod(positiveInt, negativeInt), positiveInt) | ||
| checkEvaluation(Pmod(positiveLong, negativeLong), positiveLong) | ||
| // Negative divisor (n < 0): `pmod` is only positive for a positive divisor, so these expected | ||
| // values are intentionally <= 0 (released behavior). They guard the r < 0, n < 0 path where |
There was a problem hiding this comment.
These cases only distinguish the retained (r + n) % n from the rejected r + n. Without overflow, (r + n) % n == r, so replacing that branch with plain r (which looks like an obvious simplification) would still pass all five cases while changing released results such as pmod(-1, Int.MinValue) (2147483647 -> -1) and pmod(-1L, Long.MinValue) (Long.MaxValue -> -1). checkConsistencyBetweenInterpretedAndCodegenAllowingException can't catch it either because both paths call MathUtils.pmod.
If exact compatibility is the intent, could you pin boundary cases like the following? Or this can be left to the follow-up that decides the overflow behavior (see L104).
checkEvaluation(Pmod(Literal(-1), Literal(Int.MinValue)), Int.MaxValue)
checkEvaluation(Pmod(Literal(-1L), Literal(Long.MinValue)), Long.MaxValue)| checkEvaluation(Pmod(Literal(-3), Literal(-5)), -3) | ||
| checkEvaluation(Pmod(Literal(-7), Literal(-3)), -1) | ||
| checkEvaluation(Pmod(Literal(-3L), Literal(-5L)), -3L) | ||
| checkEvaluation(Pmod(Literal((-3).toShort), Literal((-5).toShort)), (-3).toShort) | ||
| checkEvaluation(Pmod(Literal((-7).toByte), Literal((-3).toByte)), (-1).toByte) |
There was a problem hiding this comment.
nit: these could go into the existing testNumericDataTypes { convert => ... } block at the top of this test, which would cover all 7 numeric types with both pairs. Currently Int has both pairs, Long/Short only (-3, -5), and Byte only (-7, -3).
checkEvaluation(Pmod(Literal(convert(-3)), Literal(convert(-5))), convert(-3))
checkEvaluation(Pmod(Literal(convert(-7)), Literal(convert(-3))), convert(-1))…essions/ArithmeticExpressionSuite.scala Co-authored-by: Dongjoon Hyun <dongjoon@apache.org>
What changes were proposed in this pull request?
The integral
MathUtils.pmodoverloads (Int,Long,Byte,Short) compute the positiveremainder as:
The trailing
% nis redundant only when the divisorn > 0:a % nhas magnitude strictlyless than
|n|, so forr < 0andn > 0,r + nalready lies in[0, n)and the second modulois a no-op. For
n < 0,r + ncan fall belownand still needs reducing, so the% nmust bekept. This PR skips the modulo exactly where it is redundant and retains it otherwise:
The
Float/Doubleoverloads are left unchanged on purpose: floating-point rounding can roundr + nup to exactlyn, and the extra% nfolds that back to0, so there it is a realcorrectness guard rather than a no-op.
Why are the changes needed?
MathUtils.pmodbacks thepmodSQL function and the per-row partition-id expression ofHashPartitioning(Pmod(Murmur3Hash(...), numPartitions)), so it runs once per row on the mapside of every hash-partitioned shuffle -- always with a positive divisor (
numPartitions). Removingthe redundant modulo in that case takes an integer division off the hot path.
JMH microbenchmark of the three forms (16,384
pmod(int, int)calls per op, JDK 17, JMH 1.37,2 forks x 5 iterations,
AverageTime):n(r+n)%n8(n > 0)200(n > 0)-200(n < 0)For positive divisors the guarded form matches the unconditional drop (the
if (n > 0)test isfree) and is ~3.2-3.5x faster than the original. For negative divisors it correctly keeps the
modulo, matching the original cost and result. (*the drop-all column at
n < 0is fast only becauseit skips the required modulo and returns the wrong, out-of-range value.)
Does this PR introduce any user-facing change?
No. The result is identical to released Spark for every input, including negative divisors.
How was this patch tested?
ArithmeticExpressionSuitecover a negative dividend with a negativedivisor (e.g.
pmod(-3, -5) == -3) -- ther < 0,n < 0path that an unconditional drop wouldbreak and that existing tests did not exercise.
oracle: all
byteandshort(a, n)pairs, and all 2^32intdividends across positive andnegative divisors, with zero mismatches.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8